/*
 * Copyright 2008 Sun Microsystems, Inc.  All Rights Reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Sun designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Sun in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Sun Microsystems, Inc., 4150 Network Circle, Santa Clara,
 * CA 95054 USA or visit www.sun.com if you need additional information or
 * have any questions.
 */
package com.sun.lwuit.html;

import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Hashtable;

/**
 * The parser class is used to parse an XHTML-MP 1.0 document into a DOM object (Element).
 * Unsupported tags and attributes as well as comments are dropped in the parsing process.
 * 
 * @author Ofir Leitner
 */
class Parser {

    /**
     * The parser is a singleton, this static member holds its instance
     */
    static Parser instance;

    /**
     * The list of empty tags (i.e. tags that naturally don't have any children).
     * This is used to enable empty tags to be closed also in a non-strict way (i.e. &lt;br&gt; instead of &lt;br&gt/;)
     * some of these tags are not a part of the XHTML-MP 1.0 standard, but including them here allows a more smooth parsing if the document is not strictly XHTML-MP 1.0
     */
    static String[] EMPTY_TAGS = {"br","link","meta","base","area","basefont","col","frame","hr","img","input","isindex","param"};


   /**
    * This is a list of ISO 8859-1 Symbols that can be used as HTML char entities
    */
    private static final String[] CHAR_ENTITY_STRINGS = {
        "iexcl","cent","pound","curren","yen","brvbar","sect","uml","copy","ordf","laquo","not","shy","reg","macr","deg","plusmn","sup2","sup3","acute",
        "micro","para","middot","cedil","sup1","ordm","raquo","frac14","frac12","frac34","iquest","Agrave","Aacute","Acirc","Atilde","Auml","Aring","AElig",
        "Ccedil","Egrave","Eacute","Ecirc","Euml","Igrave","Iacute","Icirc","Iuml","ETH","Ntilde","Ograve","Oacute","Ocirc","Otilde","Ouml","times","Oslash",
        "Ugrave","Uacute","Ucirc","Uuml","Yacute","THORN","szlig","agrave","aacute","acirc","atilde","auml","aring","aelig","ccedil","egrave","eacute","ecirc",
        "euml","igrave","iacute","icirc","iuml","eth","ntilde","ograve","oacute","ocirc","otilde","ouml","divide","oslash","ugrave","uacute","ucirc","uuml",
        "yacute","thorn","yuml"};

   /**
    * This hashtable contains user defined char entities that can be added via HTMLComponent.addCharEntity
    */
   private static Hashtable USER_DEFINED_CHAR_ENTITIES;

    /**
     * Returns or creates the Parser's single instance
     * 
     * @return the Parser's instance
     */
    static Parser getInstance() {
        if (instance==null) {
            instance=new Parser();
        }
        return instance;
    }

    /**
     * Adds the given symbol and code to the user defined char entities table
     * 
     * @param symbol The symbol to add
     * @param code The symbol's code
     */
    static void addCharEntity(String symbol,int code) {
        if (USER_DEFINED_CHAR_ENTITIES==null) {
            USER_DEFINED_CHAR_ENTITIES=new Hashtable();
        }
        USER_DEFINED_CHAR_ENTITIES.put(trimCharEntity(symbol),new Integer(code));
    }

    /**
     * Adds the given symbols array  to the user defined char entities table with the startcode provided as the code of the first string, startcode+1 for the second etc.
     * Some strings in the symbols array may be null thus skipping code numbers.
     *
     * @param symbols The symbols to add
     * @param startcode The symbol's code
     */
    static void addCharEntitiesRange(String[] symbols,int startcode) {
        if (USER_DEFINED_CHAR_ENTITIES==null) {
            USER_DEFINED_CHAR_ENTITIES=new Hashtable();
        }
        for(int i=0;i<symbols.length;i++) {
            if (symbols[i]!=null) {
                USER_DEFINED_CHAR_ENTITIES.put(trimCharEntity(symbols[i]),new Integer(startcode+i));
            }
        }
    }

    /**
     * Trims unneeded & and ; from the symbol if exist
     *
     * @param symbol The char entity symbol
     * @return A trimmed char entity without & and ;
     */
    private static String trimCharEntity(String symbol) {
        if (symbol.startsWith("&")) {
            symbol=symbol.substring(1);
        }
        if (symbol.endsWith(";")) {
            symbol=symbol.substring(0, symbol.length()-1);
        }
        return symbol;
    }


    /**
     * This method translates between a HTML char entity string to the according char code.
     * The string is first compared to the 6 most popular strings: nbsp,quot,apos,amp,lt and gt.
     * If not found, the search continues to a wider string array of char codes 161-255 which are supported in ISO-8859-1
     * In addition 'euro' was added as it is out of the regular ISO-8859-1 table but popular.
     * 
     * @param symbol The symbol to lookup
     * @return The char code of the symbol, or -1 if none found
     */
    private static int getCharEntityCode(String symbol) {
        // First tries the most popular char entities
        if (symbol.equals("nbsp")) { // White space
            return 160;
        } else if (symbol.equals("lt")) { // less-than
            return 60;
        } else if (symbol.equals("gt")) { // greater-than
            return 62;
        } else if (symbol.equals("amp")) { // ampersand
            return 38;
        } else if (symbol.equals("quot")) { // quotation mark
            return 34;
        } else if (symbol.equals("apos")) { // apostrophe
            return 39;
        } else if (symbol.equals("bull")) { // bullet
            return 8226;
        } else if (symbol.equals("euro")) { // euro
            return 8364;
        } else {
            // Not one of the most popular char codes, proceed to check the ISO-8859-1 symbols array
            for(int i=0;i<CHAR_ENTITY_STRINGS.length;i++) {
                if (symbol.equals(CHAR_ENTITY_STRINGS[i])) {
                    return i+161; // 161 is the char code of the first char entity string
                }
            }

            // Not found in the standard symbol table, see if it is in the user defined symbols table
            if (USER_DEFINED_CHAR_ENTITIES!=null) {
                Object charObj=USER_DEFINED_CHAR_ENTITIES.get(symbol);
                if (charObj!=null) {
                    return ((Integer)charObj).intValue();
                }
            }

            // Not found anywhere
            return -1;
        }
    }


    /**
     * Converts a char entity to the matching character.
     * This handles both numbered and symbol char entities (The latter is done via getCharEntityCode)
     *
     * @param charEntity The char entity to convert
     * @return A string containing a single char, or an empty string if the char entity couldn't be converted
     */
    private String convertCharEntity(String charEntity,HTMLCallback callback) {
        int charCode=-1;
        if (charEntity.startsWith("#")) { //numbered char entity
            if (charEntity.startsWith("#x")) { //hex
                try {
                    charCode=Integer.parseInt(charEntity.substring(2),16);
                } catch (NumberFormatException nfe) {
                    //if not a number - simply ignore char entity
                }
            } else {
                try {
                    charCode=Integer.parseInt(charEntity.substring(1));
                } catch (NumberFormatException nfe) {
                    //if not a number - simply ignore char entity
                }
            }
        } else { //not numbered, rather a symbol
            charCode=getCharEntityCode(charEntity);
        }

        if (charCode!=-1) {
            return ""+(char)charCode;
        } else {
            notifyError(callback, HTMLCallback.ERROR_UNRECOGNIZED_CHAR_ENTITY,null,null,null, "Unrecognized char entity: "+charEntity);
            return "&"+charEntity+";"; // Another option is to return an empty string, but returning the entity will unravel bugs and will also allow ignoring common mistakes such as using the & char (instead of &apos;)
        }

    }

    /**
     * This is the entry point for parsing a document and the only non-private member method in this class
     *
     * @param is The InputStream containing the XML
     * @return an Element object describing the parsed document
     */
    Element parse(InputStreamReader is,HTMLCallback callback) {
        Element rootElement=new Element("ROOT"); // ROOT is a "dummy" element that all other document elements are added to
        try {
            parseTagContent(rootElement, is, callback);
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
        return rootElement;
    }

    /**
     * This method parses tags content. It accumulates text and adds it as a child element in the parent Element.
     * Upon bumping a start tag character it calls the parseTag method.
     * This method is called at first from the parse method, and later on from parseTag (which creates the recursion).
     *
     * @param element The current parent element
     * @param is The InputStream containing the XML
     * @throws IOException
     */
    private void parseTagContent(Element element,InputStreamReader is, HTMLCallback callback) throws IOException {
        String text=null;
        boolean leadingSpace=false;
        char c=(char)is.read();
        String charEntity=null;

        while((byte)c!=-1) {
            if (c=='<') {
                if (text!=null) {
                    if (charEntity!=null) { //Mistakenly "collected" something that is not a char entity, perhaps misuse of the & character (instead of using &apos;)

                        text+="&"+charEntity;
                        charEntity=null;
                    }
                    if (leadingSpace) {
                        text=" "+text;
                    }
                    Element textElement=new Element("text");
                    textElement.addAttribute("title", text);
                    element.addChild(textElement);
                    text=null;
                    leadingSpace=false;
                    
                }


                Element childElement=parseTag(is,callback);
                if (childElement==null) { //was actually an ending tag
                    String closingTag="";
                    c=(char)is.read();
                    while ((c!='>')) {
                        closingTag+=c;
                        c=(char)is.read();
                    }

                    if (closingTag.equalsIgnoreCase(element.getName())) {
                        return;
                    } else if (isEmptyTag(closingTag)) {
                        // do nothing, someone chose to close an empty tag i.e. <img ....></img> or <br></br>
                    } else {
                        notifyError(callback, HTMLCallback.ERROR_NO_CLOSE_TAG, element.getName(), null, null, "Malformed HTML - no appropriate closing tag for "+element.getName());
                        return;
                    }
                } else if (childElement.getId()!=-1) { //If tag unsupported don't add it
                    /*
                     * BlueWhaleSystems fix - Michael Busheikin, 8/4/10:
                     * Allow the callback to decide whether the element should be included.
                     */
                    if( callback.shouldIncludeElement( childElement ) )
                    {
                        element.addChild(childElement);
                    }
                }
            } else if (text!=null) {
                if (charEntity!=null) {
                    if (c==';') { //end
                        text+=convertCharEntity(charEntity,callback);
                        charEntity=null;
                    } else {
                        charEntity+=c;
                    }
                } else if (c=='&') { //start char entity
                    charEntity=""; // The & is not included in the string we accumulate
                } else {
                    text+=c;
                }
            } else if (!isWhiteSpace(c)) {
                if (c=='&') { //text starts with a character entity (i.e. &nbsp;)
                    charEntity=""; // The & is not included in the string we accumulate
                    text=""; //Initalize text so it won't be null
                } else {
                    text=""+c;
                }
            } else if (c==' ') {
                leadingSpace=true;
            }
            c=(char)is.read();
        }
    }

    /**
     * Checks if the specified character is a white space or not.
     * Exposed to packaage since used by HTMLComponent as well
     *
     * @param ch The character to check
     * @return true if the character is a white space, false otherwise
     */
    static boolean isWhiteSpace(char ch) {
        return ((ch==' ') || (ch=='\n') || (ch=='\t') || (ch==10) || (ch==13));
    }

    /**
     * This method collects the tag name and all of its attributes.
     * For comments and XML declarations this will call the parseCommentOrXMLDeclaration method.
     * Note that this method returns an Element with a name/id and attrbutes, but not its content which will be done by parseTagContent
     * 
     * @param is The InputStream containing the XML
     * @return The parsed element
     * @throws IOException
     */
    private Element parseTag(InputStreamReader is,HTMLCallback callback) throws IOException {
        String tagName="";
        String curAttribute="";
        String curValue="";

        char c=(char)is.read();
        if (c=='/') {
            return null; //end tag
        } else if (c=='!') {
            c=(char)is.read();
            char c2=(char)is.read();
            if ((c=='-') && (c2=='-')) { //comment
                return parseCommentOrXMLDeclaration(is,"-->");
            } else {
                return parseCommentOrXMLDeclaration(is,">"); //parse doctypes i.e. <!DOCTYPE .... > as comments as well - i.e. ignore them
            }
        } else if (c=='?') {
            return parseCommentOrXMLDeclaration(is,">"); //parse XML declaration i.e. <?xml version="1.0" encoding="ISO-8859-1"?> as comments as well - i.e. ignore them
        }

         //read and ignore any whitespaces before tag name
        while (isWhiteSpace(c)) {
            c=(char)is.read();
        }

        //collect tag name
        while ((!isWhiteSpace(c)) && (c!='>') && (c!='/')) {
            tagName+=c;
            c=(char)is.read();
        }

         //read and ignore any whitespaces after tag name
        while (isWhiteSpace(c)) {
            c=(char)is.read();
        }

        tagName=tagName.toLowerCase();
        Element element=new Element(tagName);

        if (element.getId()==-1) {
            notifyError(callback, HTMLCallback.ERROR_TAG_NOT_SUPPORTED, tagName, null, null, "The tag '"+tagName+"' is not supported in XHTML-MP 1.0");

            // If tag is not supported we skip it all till the closing tag.
            // This is especially important for the script tag which may contain '<' and '>' which might confuse the parser
            
            /*
             * BlueWhaleSystems fix - Michael Busheikin, 13/4/10:
             * The tag could also be a closed tag of the form "<name />"
             */
            boolean isClosedTag=false;
            while( true ) {
                if(c=='>') {
                    // end of the tag.
                    break;
                } else if(c=='/') {
                    c = (char)is.read();
                    if(c=='>') {
                        isClosedTag = true;
                        break;
                    }
                }
                c=(char)is.read();
            }
            if( !isClosedTag ) {
                String endTag="</"+tagName+">";
                int index=0;
                while(index<endTag.length()) {
                    c=(char)is.read();
    
                    if ((c>='A') && (c<='Z')) {
                        c=(char)(c-'A'+'a');
                    }
                    if (c==endTag.charAt(index)) {
                        index++;
                    } else {
                        index=0;
                    }
                }
            }

            return element;
        }

        if (c=='>') { //tag declartion ended, process content
            if (!isEmptyTag(tagName)) {
                parseTagContent(element, is, callback);
            }
            return element;
        } else if (c=='/') { //closed tag - no content
            c=(char)is.read();
            if (c=='>') {
                return element;
            } else {
                notifyError(callback, HTMLCallback.ERROR_UNEXPECTED_CHARACTER, tagName, null, null, "HTML malformed - no > after /");
            }
        }


        while(true) {
            curAttribute=""+c;
            c=(char)is.read();
            while ((!isWhiteSpace(c)) && (c!='=') && (c!='>')) {
                curAttribute+=c;
                c=(char)is.read();
            }

            if (c=='>') { // tag close char shouldn't be found here, but if the HTML is slightly malformed we return the element
                notifyError(callback, HTMLCallback.ERROR_UNEXPECTED_TAG_CLOSING, tagName,curAttribute,null, "Unexpected tag closing in tag "+tagName+", attribute="+curAttribute);
                if (!isEmptyTag(tagName)) {
                    parseTagContent(element, is, callback);
                }
                return element;
            }

             //read and ignore any whitespaces after attribute name
            while (isWhiteSpace(c)) {
                c=(char)is.read();
            }

            if (c!='=') {
                notifyError(callback, HTMLCallback.ERROR_UNEXPECTED_CHARACTER, tagName, curAttribute, null, "Unexpected character "+c+", expected '=' after attribute "+curAttribute+" in tag "+tagName);
                if (c=='>') { // tag close char shouldn't be found here, but if the HTML is slightly malformed we return the element
                    if (!isEmptyTag(tagName)) {
                        parseTagContent(element, is, callback);
                    }
                    return element;
                }


                continue; //if attribute is not followed by = then process the next attribute
            }

            c=(char)is.read();
             //read and ignore any whitespaces before attribute value
            while (isWhiteSpace(c)) {
                c=(char)is.read();
            }

            char quote=' ';


            if ((c=='"') || (c=='\'')) {
                quote=c;
            } else {
                curValue+=c;
            }

            String charEntity=null;
            boolean ended=false;
            while (!ended) {
                c=(char)is.read();
                if (c==quote) {
                    ended=true;
                    c=(char)is.read();
                } else if ((quote==' ') && ((c=='/') || (c=='>') || (isWhiteSpace(c)))) {
                    ended=true;
                } else if (c=='&') {
                    if (charEntity!=null) {
                        curValue+="&"+charEntity; // Wasn't a char entit, probably a url as a parameter : i.e. param="/test?p=val&pw=val2&p3=val3
                    }
                    charEntity="";
                } else {
                    if (charEntity!=null) {
                        if (c==';') {
                            curValue+=convertCharEntity(charEntity,callback);
                            charEntity=null;
                        } else {
                            charEntity+=c;
                        }
                    } else {
                        curValue+=c;
                    }
                }
            }

            if (charEntity!=null) { // Mistaken something else for a char entity - for example an action which is action="http://domain/test.html?param1=val1&param2=val2"
                curValue+="&"+charEntity;
                charEntity=null;
            }

            curAttribute=curAttribute.toLowerCase();
            int error=element.addAttribute(curAttribute, curValue);

            if (error==HTMLCallback.ERROR_ATTRIBUTE_NOT_SUPPORTED) {
                notifyError(callback, error, tagName, curAttribute, curValue, "Attribute '"+curAttribute+"' is not supported for tag '"+tagName+"'. Supported attributes: "+element.getSupportedAttributesList());
            } else if (error==HTMLCallback.ERROR_ATTIBUTE_VALUE_INVALID) {
                notifyError(callback, error, tagName, curAttribute, curValue, "Attribute '"+curAttribute+"' in tag '"+tagName+"' has an invalid value ("+curValue+")");
            }

             //read and ignore any whitespaces after attribute/value pair
            while (isWhiteSpace(c)) {
                c=(char)is.read();
            }

            if (c=='>') { //tag declartion ended, process content
                if (!isEmptyTag(tagName)) {
                    parseTagContent(element, is, callback);
                }
                return element;
            } else if (c=='/') { //closed tag - no content
                c=(char)is.read();
                if (c=='>') {
                    return element;
                } else {
                    notifyError(callback, HTMLCallback.ERROR_UNEXPECTED_CHARACTER, tagName, curAttribute, curValue, "HTML malformed - no > after /");
                    //throw new IllegalArgumentException("HTML malformed - no > after / - 2, instead: "+((byte)c));
                }
            }

            curAttribute="";
            curValue="";

        }

    }

    /**
     * This utility method is used to parse comments and XML declarations in the HTML.
     * The comment/declaration is returned as an Element.
     * In the current implementation they will be ommitted from the final DOM (=the root element) as the tag name won't match supported tags.
     *
     * @param is The inputstream
     * @param endTag The endtag to look for
     * @return
     * @throws IOException
     */
    private Element parseCommentOrXMLDeclaration(InputStreamReader is,String endTag) throws IOException {
        int endTagPos=0;
        String text="";
        boolean ended=false;
        while (!ended) {
            char c=(char)is.read();
            if (c==endTag.charAt(endTagPos)) {
                endTagPos++;
                if (endTagPos==endTag.length()) {
                    ended=true;
                }
            } else {
                if (endTagPos!=0) { //add - or -- if it wasn't an end tag eventually
                    text+=endTag.substring(0, endTagPos);
                    endTagPos=0;
                }
                text+=c;
            }
        }

        String elementName=null;
        if (endTag.equals("-->")) {
            elementName="comment";
        } else if (endTag.equals(">")) {
            elementName="XML declaration";
        }

        Element comment = new Element(elementName);
        comment.addAttribute("content", text);
        return comment;
    }

    /**
     * Checks whether the specified tag is an empty tag as defined in EMPTY_TAGS
     *
     * @param tagName The tag name to check
     * @return true if that tag is defined as an empty tag, false otherwise
     */
    private boolean isEmptyTag(String tagName) {
        int i=0;
        boolean found=false;
        while ((i<EMPTY_TAGS.length) && (!found)) {
            if (tagName.equals(EMPTY_TAGS[i])) {
                found=true;
            }
            i++;
        }
        return found;

    }

    /**
     * A utility method used to notify an error to the HTMLCallback and throw an IllegalArgumentException if parsingError returned false
     *
     * @param callback The HTMLCallback
     * @param errorId The error ID, one of the ERROR_* constants in HTMLCallback
     * @param tag The tag in which the error occured (Can be null for non-tag related errors)
     * @param attribute The attribute in which the error occured (Can be null for non-attribute related errors)
     * @param value The value in which the error occured (Can be null for non-value related errors)
     * @param description A verbal description of the error
     */
    private static void notifyError(HTMLCallback callback, int errorId,String tag, String attribute,String value,String description) {
        if (callback!=null) {
            boolean cont=callback.parsingError(errorId,tag,attribute,value,description);
            if (!cont) {
                throw new IllegalArgumentException(description);
            }
        } 
    }

}
